import * as THREE from 'three';
import { TilesRenderer } from '3d-tiles-renderer';
import {
    GLTFStructuralMetadataExtension,
    GLTFMeshFeaturesExtension,
    GLTFCesiumRTCExtension,
    CesiumIonAuthPlugin,
    GoogleCloudAuthPlugin,
    ImplicitTilingPlugin,
// eslint-disable-next-line import/no-unresolved
} from '3d-tiles-renderer/plugins';

import GeometryLayer from 'Layer/GeometryLayer';
import iGLTFLoader from 'Parser/iGLTFLoader';
// eslint-disable-next-line import/extensions, import/no-unresolved
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
// eslint-disable-next-line import/extensions, import/no-unresolved
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import PointsMaterial, {
    PNTS_MODE,
    PNTS_SHAPE,
    PNTS_SIZE_MODE,
    ClassificationScheme,
} from 'Renderer/PointsMaterial';
import { VIEW_EVENTS } from 'Core/View';

const _raycaster = new THREE.Raycaster();

// Stores lruCache, downloadQueue and parseQueue for each id of view {@link View}
// every time a tileset has been added
// https://github.com/iTowns/itowns/issues/2426
const viewers = {};

// Internal instance of GLTFLoader, passed to 3d-tiles-renderer-js to support GLTF 1.0 and 2.0
// Temporary exported to be used in deprecated B3dmParser
export const itownsGLTFLoader = new iGLTFLoader();
itownsGLTFLoader.register(() => new GLTFMeshFeaturesExtension());
itownsGLTFLoader.register(() => new GLTFStructuralMetadataExtension());
itownsGLTFLoader.register(() => new GLTFCesiumRTCExtension());

export const OGC3DTILES_LAYER_EVENTS = {
    /**
     * Fired when a new root or child tile set is loaded
     * @event OGC3DTilesLayer#load-tile-set
     * @type {Object}
     * @property {Object} tileset - the tileset json parsed in an Object
     * @property {String} url - tileset url
     */
    LOAD_TILE_SET: 'load-tile-set',
    /**
     * Fired when a tile model is loaded
     * @event OGC3DTilesLayer#load-model
     * @type {Object}
     * @property {THREE.Group} scene - the model (tile content) parsed in a THREE.GROUP
     * @property {Object} tile - the tile metadata from the tileset
     */
    LOAD_MODEL: 'load-model',
    /**
     * Fired when a tile model is disposed
     * @event OGC3DTilesLayer#dispose-model
     * @type {Object}
     * @property {THREE.Group} scene - the model (tile content) that is disposed
     * @property {Object} tile - the tile metadata from the tileset
     */
    DISPOSE_MODEL: 'dispose-model',
    /**
     * Fired when a tiles visibility changes
     * @event OGC3DTilesLayer#tile-visibility-change
     * @type {Object}
     * @property {THREE.Group} scene - the model (tile content) parsed in a THREE.GROUP
     * @property {Object} tile - the tile metadata from the tileset
     * @property {boolean} visible - the tile visible state
     */
    TILE_VISIBILITY_CHANGE: 'tile-visibility-change',
    /**
     * Fired when a new batch of tiles start loading (can be fired multiple times, e.g. when the camera moves and new tiles
     * start loading)
     * @event OGC3DTilesLayer#tiles-load-start
     */
    TILES_LOAD_START: 'tiles-load-start',
    /**
     * Fired when all visible tiles are loaded (can be fired multiple times, e.g. when the camera moves and new tiles
     * are loaded)
     * @event OGC3DTilesLayer#tiles-load-end
     */
    TILES_LOAD_END: 'tiles-load-end',
};

/**
 * Enable loading 3D Tiles with [Draco](https://google.github.io/draco/) geometry extension.
 *
 * @param {String} path path to draco library folder containing the JS and WASM decoder libraries. They can be found in
 * [itowns examples](https://github.com/iTowns/itowns/tree/master/examples/libs/draco).
 * @param {Object} [config] optional configuration for Draco decoder (see threejs'
 * [setDecoderConfig](https://threejs.org/docs/index.html?q=draco#examples/en/loaders/DRACOLoader.setDecoderConfig) that
 * is called under the hood with this configuration for details.
 */
export function enableDracoLoader(path, config) {
    if (!path) {
        throw new Error('Path to draco folder is mandatory');
    }
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath(path);
    if (config) {
        dracoLoader.setDecoderConfig(config);
    }
    itownsGLTFLoader.setDRACOLoader(dracoLoader);
}

/**
 * Enable loading 3D Tiles with [KTX2](https://www.khronos.org/ktx/) texture extension.
 *
 * @param {String} path path to ktx2 library folder containing the JS and WASM decoder libraries. They can be found in
 * [itowns examples](https://github.com/iTowns/itowns/tree/master/examples/libs/basis).
 * @param {THREE.WebGLRenderer} renderer the threejs renderer
 */
export function enableKtx2Loader(path, renderer) {
    if (!path || !renderer) {
        throw new Error('Path to ktx2 folder and renderer are mandatory');
    }
    const ktx2Loader = new KTX2Loader();
    ktx2Loader.setTranscoderPath(path);
    ktx2Loader.detectSupport(renderer);
    itownsGLTFLoader.setKTX2Loader(ktx2Loader);
}

/**
 * Enable loading 3D Tiles and GLTF with
 * [meshopt](https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Vendor/EXT_meshopt_compression/README.md) compression extension.
 *
 * @param {MeshOptDecoder.constructor} MeshOptDecoder - The Meshopt decoder
 * module.
 *
 * @example
 * import * as itowns from 'itowns';
 * import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
 *
 * // Enable support of EXT_meshopt_compression
 * itowns.enableMeshoptDecoder(MeshoptDecoder);
 */
export function enableMeshoptDecoder(MeshOptDecoder) {
    if (!MeshOptDecoder) {
        throw new Error('MeshOptDecoder module is mandatory');
    }
    itownsGLTFLoader.setMeshoptDecoder(MeshOptDecoder);
}

const noSetter = () => {
    console.warn('[OGC3DTilesLayer] Material property cannot be set from the material, use layer properties instead');
};

/**
 * Patches material properties to automatically update the material (uniforms
 * and shader) when the layer properties are updated. Note that:
 * - The transparent property is set according to the opacity. This leads
 *   to the recompilation of the material if not cached.
 * - The material properties cannot be set from within the material, so the
 *   setters are not implemented and shall not be used.
 * @param {Material} material A three.js material
 * @param {OGC3DTilesLayer} layer An OGC3DTilesLayer
 */
function referMaterialProperties(material, layer) {
    let opacity = material.opacity;
    Object.defineProperty(material, 'opacity', {
        get: () => layer.opacity * opacity,
        set: (value) => { opacity = value; },
    });

    let transparent = material.transparent;
    let tPrev = transparent;
    Object.defineProperty(material, 'transparent', {
        get: () => {
            const t = material.opacity < 1.0 || transparent;
            if (t != tPrev) {
                material.needsUpdate = true;
                tPrev = t;
            }
            return t;
        },
        set: (value) => { transparent = value; },
    });

    let wireframe = material.wireframe;
    Object.defineProperty(material, 'wireframe', {
        get: () => layer.wireframe || wireframe,
        set: (value) => { wireframe = value; },
    });
}

/**
 * Patches material properties to automatically update the material (uniforms
 * and shader) when the layer properties are updated. Note that:
 * - The transparent property is set according to the opacity **and** the
 *   presence of non-opaque pixels in the classification texture. This leads
 *   to the recompilation of the material if not cached.
 * - The material properties cannot be set from within the material, so the
 *   setters are not implemented and shall not be used.
 * @param {PointsMaterial} material An itowns PointsMaterial
 * @param {OGC3DTilesLayer} layer An OGC3DTilesLayer
 */
function referPointsMaterialProperties(material, layer) {
    let transparent = material.transparent;
    let tPrev = transparent;
    Object.defineProperty(material, 'transparent', {
        get: () => {
            const t = material.opacity < 1.0
                || material.classificationTexture.userData.transparent
                || material.visibilityTexture.userData.transparent
                || transparent;
            if (t != tPrev) {
                material.needsUpdate = true;
                tPrev = t;
            }
            return t;
        },
        set: (value) => { transparent = value; },
    });

    let wireframe = material.wireframe;
    Object.defineProperty(material, 'wireframe', {
        get: () => layer.wireframe || wireframe,
        set: (value) => { wireframe = value; },
    });

    let opacity = material.uniforms.opacity.value;
    Object.defineProperty(material.uniforms.opacity, 'value', {
        get: () => layer.opacity * opacity,
        set: (value) => { opacity = value; },
    });

    Object.defineProperty(material.uniforms.mode, 'value', {
        get: () => layer.pntsMode,
        set: noSetter,
    });

    Object.defineProperty(material.uniforms.shape, 'value', {
        get: () => layer.pntsShape,
        set: noSetter,
    });

    Object.defineProperty(material.uniforms.sizeMode, 'value', {
        get: () => layer.pntsSizeMode,
        set: noSetter,
    });

    Object.defineProperty(material.uniforms.minAttenuatedSize, 'value', {
        get: () => layer.pntsMinAttenuatedSize,
        set: noSetter,
    });

    Object.defineProperty(material.uniforms.maxAttenuatedSize, 'value', {
        get: () => layer.pntsMaxAttenuatedSize,
        set: noSetter,
    });

    Object.defineProperty(material.uniforms.scale, 'value', {
        get: () => layer.scale,
        set: noSetter,
    });
}

async function getMeshFeatures(meshFeatures, options) {
    const { faceIndex, barycoord } = options;

    const features = await meshFeatures.getFeaturesAsync(faceIndex, barycoord);
    return {
        features,
        featureIds: meshFeatures.getFeatureInfo(),
    };
}

function getStructuralMetadata(structuralMetadata, options) {
    const { index, faceIndex, barycoord, tableIndices, features } = options;

    const tableData = [];
    if (tableIndices !== undefined && features !== undefined) {
        structuralMetadata.getPropertyTableData(
            tableIndices,
            features,
            tableData,
        );
    }

    const attributeData = [];
    if (index !== undefined) {
        structuralMetadata.getPropertyAttributeData(index, attributeData);
    }

    const textureData = [];
    if (faceIndex !== undefined) {
        structuralMetadata.getPropertyTextureData(
            faceIndex,
            barycoord,
            textureData,
        );
    }

    const metadata = [
        ...tableData,
        ...textureData,
        ...attributeData,
    ];

    return metadata;
}

async function getMetadataFromIntersection(intersection) {
    const { point, object, face, faceIndex } = intersection;
    const { meshFeatures, structuralMetadata } = object.userData;

    const barycoord = new THREE.Vector3();
    if (face) {
        const position = object.geometry.getAttribute('position');
        const triangle = new THREE.Triangle().setFromAttributeAndIndices(
            position,
            face.a,
            face.b,
            face.c,
        );
        triangle.a.applyMatrix4(object.matrixWorld);
        triangle.b.applyMatrix4(object.matrixWorld);
        triangle.c.applyMatrix4(object.matrixWorld);
        triangle.getBarycoord(point, barycoord);
    } else {
        barycoord.set(0, 0, 0);
    }

    // EXT_mesh_features
    const { features, featureIds } = meshFeatures ? await getMeshFeatures(meshFeatures, {
        faceIndex,
        barycoord,
    }) : {};
    const tableIndices = featureIds?.map(p => p.propertyTable);

    // EXT_structural_metadata
    const metadata = structuralMetadata ? getStructuralMetadata(structuralMetadata, {
        ...intersection,
        barycoord,
        tableIndices,
        features,
    }) : [];

    return metadata;
}

class OGC3DTilesLayer extends GeometryLayer {
    /**
     * Layer for [3D Tiles](https://www.ogc.org/standard/3dtiles/) datasets.
     *
     * Advanced configuration note: 3D Tiles rendering is delegated to 3DTilesRendererJS that exposes several
     * configuration options accessible through the tilesRenderer property of this class. see the
     * [3DTilesRendererJS doc](https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/master/README.md). Also note that
     * the cache is shared amongst 3D tiles layers and can be configured through tilesRenderer.lruCache (see the
     * [following documentation](https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/master/README.md#lrucache-1).
     *
     * @extends Layer
     *
     * @param {String} id - unique layer id.
     * @param {Object} config - layer specific configuration
     * @param {OGC3DTilesSource} config.source - data source configuration
     * @param {String} [config.pntsMode = PNTS_MODE.COLOR] Point cloud coloring mode (passed to {@link PointsMaterial}).
     *      Only 'COLOR' or 'CLASSIFICATION' are possible. COLOR uses RGB colors of the points,
     *      CLASSIFICATION uses a classification property of the batch table to color points.
     * @param {ClassificationScheme}  [config.classificationScheme = ClassificationScheme.DEFAULT]  {@link PointsMaterial} classification scheme
     * @param {String} [config.pntsShape = PNTS_SHAPE.CIRCLE] Point cloud point shape. Only 'CIRCLE' or 'SQUARE' are possible.
     * (passed to {@link PointsMaterial}).
     * @param {String} [config.pntsSizeMode = PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode (passed to {@link PointsMaterial}).
     * Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance
     * from point to camera.
     * @param {Number} [config.pntsMinAttenuatedSize = 3] Minimum scale used by 'ATTENUATED' size mode.
     * @param {Number} [config.pntsMaxAttenuatedSize = 10] Maximum scale used by 'ATTENUATED' size mode.
     */
    constructor(id, config) {
        const {
            pntsMode = PNTS_MODE.COLOR,
            pntsShape = PNTS_SHAPE.CIRCLE,
            classification = ClassificationScheme.DEFAULT,
            pntsSizeMode = PNTS_SIZE_MODE.VALUE,
            pntsMinAttenuatedSize = 3,
            pntsMaxAttenuatedSize = 10,
            ...geometryLayerConfig
        } = config;
        super(id, new THREE.Group(), geometryLayerConfig);

        this.isOGC3DTilesLayer = true;
        // Store points material config so they can be used later to substitute points tiles material
        // by our own PointsMaterial. These properties should eventually be managed through the Style API
        // (see https://github.com/iTowns/itowns/issues/2336)
        this.pntsMode = pntsMode;
        this.pntsShape = pntsShape;
        this.classification = classification;
        this.pntsSizeMode = pntsSizeMode;
        this.pntsMinAttenuatedSize = pntsMinAttenuatedSize;
        this.pntsMaxAttenuatedSize = pntsMaxAttenuatedSize;

        /** @type{any} */
        this.tilesRenderer = new TilesRenderer(this.source.url);
        if (config.source.isOGC3DTilesIonSource) {
            this.tilesRenderer.registerPlugin(new CesiumIonAuthPlugin({
                apiToken: config.source.accessToken,
                assetId: config.source.assetId,
                autoRefreshToken: true,
            }));
        } else if (config.source.isOGC3DTilesGoogleSource) {
            this.tilesRenderer.registerPlugin(new GoogleCloudAuthPlugin({
                apiToken: config.source.key,
                autoRefreshToken: true,
            }));
        }
        this.tilesRenderer.registerPlugin(new ImplicitTilingPlugin());

        this.tilesRenderer.manager.addHandler(/\.gltf$/, itownsGLTFLoader);

        this.object3d.add(this.tilesRenderer.group);

        // Add an initialization step that is resolved when the root tileset is loaded (see this._setup below), meaning
        // that the layer will be marked ready when the tileset has been loaded.
        this._res = this.addInitializationStep();

        /**
         * @type {number}
         */
        this.sseThreshold = this.tilesRenderer.errorTarget;
        Object.defineProperty(this, 'sseThreshold', {
            get() { return this.tilesRenderer.errorTarget; },
            set(value) { this.tilesRenderer.errorTarget = value; },
        });

        if (config.sseThreshold) {
            this.sseThreshold = config.sseThreshold;
        }

        // Used for custom schedule callbacks (VR)
        this.tasks = [];
        this.tilesSchedulingCB = (func) => {
            this.tasks.push(func);
        };
    }

    /**
     * Sets the lruCache and download and parse queues so they are shared amongst
     * all tilesets from a same {@link View} view.
     * @param {View} view - view associated to this layer.
     * @private
     */
    _setupCacheAndQueues(view) {
        const id = view.id;
        // Share the caches and queues to cut down on memory and correctly prioritize downloads.
        // https://github.com/NASA-AMMOS/3DTilesRendererJS?tab=readme-ov-file#multiple-tilesrenderers-with-shared-caches-and-queues
        if (viewers[id]) {
            this.tilesRenderer.lruCache = viewers[id].lruCache;
            this.tilesRenderer.downloadQueue = viewers[id].downloadQueue;
            this.tilesRenderer.parseQueue = viewers[id].parseQueue;

            // Add this layer's callback to the map
            viewers[id].layerCallbacks[this.id] = this.tilesSchedulingCB;
        } else {
            // Create a combined callback that calls all layer callbacks
            const combinedCallback = (func) => {
                Object.values(viewers[id].layerCallbacks).forEach((callback) => {
                    callback(func);
                });
            };

            // Set the combined callback
            // We set our scheduling callback for tiles downloading and parsing -> MANDATORY for VR
            // (WebXR session has its own requestAnimationFrame method separate from that of the window
            //  https://github.com/NASA-AMMOS/3DTilesRendererJS/issues/213#issuecomment-947943386)
            // Necessary to update rendering of the tiles in VR
            // Example: https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/de25d27dc0e75278962b5f401faee30f8dce2fe0/example/vr.js
            this.tilesRenderer.downloadQueue.schedulingCallback = combinedCallback;
            this.tilesRenderer.parseQueue.schedulingCallback = combinedCallback;

            viewers[id] = {
                lruCache: this.tilesRenderer.lruCache,
                downloadQueue: this.tilesRenderer.downloadQueue,
                parseQueue: this.tilesRenderer.parseQueue,
                layerCallbacks: {
                    [this.id]: this.tilesSchedulingCB,
                },
            };
            view.addEventListener(VIEW_EVENTS.DISPOSED, (evt) => {
                delete viewers[evt.target.id];
            });
        }

        // Store the view reference for cleanup
        this._viewId = id;
    }

    /**
     * Binds 3d-tiles-renderer events to this layer.
     * @private
     */
    _setupEvents() {
        for (const ev of Object.values(OGC3DTILES_LAYER_EVENTS)) {
            this.tilesRenderer.addEventListener(ev, (e) => {
                this.dispatchEvent(e);
            });
        }
    }

    /**
     * Setup 3D tiles renderer js TilesRenderer with the camera, binds events and start updating. Executed when the
     * layer has been added to the view.
     * @param {View} view - the view the layer has been added to.
     * @private
     */
    _setup(view) {
        this.tilesRenderer.setCamera(view.camera3D);
        this.tilesRenderer.setResolutionFromRenderer(view.camera3D, view.renderer);
        // Setup whenReady to be fullfiled when the root tileset has been loaded
        let rootTilesetLoaded = false;
        this.tilesRenderer.addEventListener('load-tile-set', () => {
            view.notifyChange(this);
            if (!rootTilesetLoaded) {
                rootTilesetLoaded = true;
                this._res();
            }
        });
        this.tilesRenderer.addEventListener('load-model', (e) => {
            const { scene } = e;
            scene.traverse((obj) => {
                this._assignFinalMaterial(obj);
                this._assignFinalAttributes(obj);
            });
            view.notifyChange(this);
        });

        this._setupCacheAndQueues(view);
        this._setupEvents();

        // Start loading tileset and tiles
        this.tilesRenderer.update();
    }

    /**
     * Replace materials from GLTFLoader by our own custom materials. Note that
     * the replaced materials are not compiled yet and will be disposed by the
     * GC.
     * @param {Object3D} model
     * @private
     */
    _assignFinalMaterial(model) {
        if (!model.isMesh && !model.isPoints) {
            return;
        }

        let material = model.material;

        if (model.isPoints) {
            const pointsMaterial = new PointsMaterial({
                mode: this.pntsMode,
                shape: this.pntsShape,
                classificationScheme: this.classification,
                sizeMode: this.pntsSizeMode,
                minAttenuatedSize: this.pntsMinAttenuatedSize,
                maxAttenuatedSize: this.pntsMaxAttenuatedSize,
            });
            pointsMaterial.copy(material);
            material = pointsMaterial;
            referPointsMaterialProperties(material, this);
        } else {
            referMaterialProperties(material, this);
        }

        model.material = material;
    }

    /**
     * @param {Object3D} model
     * @private
     */
    _assignFinalAttributes(model) {
        const geometry = model.geometry;
        const batchTable = model.batchTable;

        // Setup classification bufferAttribute
        if (model.isPoints) {
            const classificationData = batchTable?.getPropertyArray('Classification');
            if (classificationData) {
                geometry.setAttribute('classification',
                    new THREE.BufferAttribute(classificationData, 1),
                );
            }
        }
    }

    handleTasks() {
        for (let t = 0, l = this.tasks.length; t < l; t++) {
            this.tasks[t]();
        }
        this.tasks.length = 0;
    }

    preUpdate(context) {
        this.scale = context.camera._preSSE;
        this.handleTasks();
        this.tilesRenderer.update();
        return null; // don't return any element because 3d-tiles-renderer already updates them
    }

    update() {
        // empty, elements are updated by 3d-tiles-renderer
    }

    /**
     * Deletes the layer and frees associated memory
     */
    delete() {
        // Clean up the callback reference from the shared callbacks
        if (this._viewId != null && viewers[this._viewId]?.layerCallbacks) {
            delete viewers[this._viewId].layerCallbacks[this.id];

            // If no more layers are using this view's queues, clean up completely
            if (Object.keys(viewers[this._viewId].layerCallbacks).length === 0) {
                delete viewers[this._viewId];
            }
        }

        this.tilesRenderer.dispose();

        // Clean up references
        this.tilesSchedulingCB = null;
        this._viewId = null;
    }

    /**
     * Get the [metadata](https://github.com/CesiumGS/3d-tiles/tree/main/specification/Metadata)
     * of the closest intersected object from a list of intersections.
     *
     * This method retrieves structured metadata stored in GLTF 2.0 assets using
     * the [`EXT_structural_metadata`](https://github.com/CesiumGS/glTF/tree/3d-tiles-next/extensions/2.0/Vendor/EXT_structural_metadata)
     * extension.
     *
     * Internally, it uses the closest intersected point to index metadata
     * stored in property attributes and textures.
     *
     * If present in GLTF 2.0 assets, this method leverages the
     * [`EXT_mesh_features`](`https://github.com/CesiumGS/glTF/tree/3d-tiles-next/extensions/2.0/Vendor/EXT_mesh_features)
     * extension and the returned featured to index metadata stored in property tables.
     *
     * @param {Array<THREE.Intersection>} intersections
     * @returns {Promise<Object | null>} - the intersected object's metadata
     */
    async getMetadataFromIntersections(intersections) {
        if (!intersections.length) { return null; }

        const metadata = await getMetadataFromIntersection(intersections[0]);
        return metadata;
    }

    /**
     * Get the attributes for the closest intersection from a list of
     * intersects.
     * @param {Array<THREE.Intersection>} intersects -  An array containing all
     * objects picked under mouse coordinates computed with view.pickObjectsAt(..).
     * @returns {Object | null} - An object containing
     */
    getC3DTileFeatureFromIntersectsArray(intersects) {
        if (!intersects.length) { return null; }

        const { face, index, object, instanceId } = intersects[0];

        /** @type{number|null} */
        let batchId;
        if (object.isPoints && index != null) {
            batchId = object.geometry.getAttribute('_batchid')?.getX(index) ?? index;
        } else if (object.isMesh && face) {
            batchId = object.geometry.getAttribute('_batchid')?.getX(face.a) ?? instanceId;
        }

        if (batchId === undefined) {
            return null;
        }

        let tileObject = object;
        while (!tileObject.batchTable) {
            tileObject = tileObject.parent;
        }

        return tileObject.batchTable.getDataFromId(batchId);
    }

    /**
     * Get all 3D objects (mesh and points primitives) as intersects at the
     * given non-normalized screen coordinates.
     * @param {View} view - The view instance.
     * @param {THREE.Vector2} coords - The coordinates to pick in the view. It
     * should have at least `x` and `y` properties.
     * @param {number} radius - Radius of the picking circle.
     * @param {Array} [target=[]] - Target array to push results too
     * @returns {Array} Array containing all target objects picked under the
     * specified coordinates.
     */
    pickObjectsAt(view, coords, radius, target = []) {
        const camera = view.camera.camera3D;
        _raycaster.setFromCamera(view.viewToNormalizedCoords(coords), camera);
        _raycaster.near = camera.near;
        _raycaster.far = camera.far;

        _raycaster.firstHitOnly = true;
        const picked = _raycaster.intersectObject(this.tilesRenderer.group, true);
        // Store the layer of the picked object to conform to the interface of what's returned by Picking.js (used for
        // other GeometryLayers
        picked.forEach((p) => { p.layer = this; });
        target.push(...picked);

        return target;
    }

    // eslint-disable-next-line no-unused-vars
    attach(layer) {
        console.warn('[OGC3DTilesLayer]: Attaching / detaching layers is not yet implemented for OGC3DTilesLayer.');
    }

    // eslint-disable-next-line no-unused-vars
    detach(layer) {
        console.warn('[OGC3DTilesLayer]: Attaching / detaching layers is not yet implemented for OGC3DTilesLayer.');
        return true;
    }

    // eslint-disable-next-line no-unused-vars
    getObjectToUpdateForAttachedLayers(obj) {
        return null;
    }

    /**
     * Executes a callback for each tile of this layer tileset.
     *
     * @param {Function} callback The callback to execute for each tile. Has the following two parameters:
     *  1. tile (Object) - the JSON tile
     *  2. scene (THREE.Object3D | null) - The tile content. Contains a `batchTable` property. Can be null if the tile
     *  has not yet been loaded.
     */
    forEachTile(callback) {
        this.tilesRenderer.traverse((tile) => {
            callback(tile, tile.cached.scene);
        });
    }
}

export default OGC3DTilesLayer;
